iT邦幫忙

2025 iThome 鐵人賽

DAY 4
0

前言

嘿,歡迎回來!經過昨天的「純開會」行程,你可能覺得有點手癢,想開始寫點程式碼了。昨天我們定義了使用者故事、畫了架構圖和線框稿,把整個「AI前端面試官」專案的輪廓都描繪出來了。這很棒,因為清晰的目標是成功的一半。

今天,我們要來實作藍圖中的第一個小方塊:題庫系統。

回想一下 Day 2 的專案,我們的題目是直接寫死在程式碼裡的 (const question = '請解釋 JavaScript 中的 hoisting 是什麼?')。這在做Protoyping時完全沒問題,但我們心裡都很清楚,我們遲早要跟這種硬編碼的行為說再見的,要打造一個真正的應用,我們需要一個更靈活、可擴充的方式來管理題目,這就會牽扯到資料庫的使用。

不過別擔心,我不會一開始就叫你裝什麼 MySQL、PostgreSQL 或 MongoDB,他們都是很優秀的選擇!但在最一開始的概念驗證階段,任何額外的設置都可能會勸退一批讀者,在台上奮力表演,結果底下沒人看也是挺沒意思的!盡可能快速地端出概念驗證後再做優化也是個常見的選擇(就像你被主管要求快速做個POC去驗證某個套件或概念,你也不該過度複雜化),但你也不用覺得既然這只是暫時的舉措,今天做的東西是不是就是做白工,看到成果後,之後我們會將今天的實作加入完整的概念搬到真正的資料庫上。

今日目標

今天結束時,我們那個雖然規劃好但還沒套用新UI的難看應用程式將會:

  • 擁有一個結構化的 JSON 檔案來存放所有面試題目。
  • 建立一個專門用來讀取題目的後端 API。
  • 在頁面載入時,動態地從後端 API 抓取一個隨機題目並顯示出來。

Step 1: 設計我們的資料結構

在建立檔案之前,先定義好我們的資料長什麼樣子,這是一個好習慣,也是避免Typescript抱怨的必要措施。我們得先思考根據我們的目標,一個這樣的練習題目需要哪些資訊呢?快速過一下腦袋後覺得至少要有:

  • id: 一個獨一無二的編號,方便未來管理。
  • topic: 題目所屬的主題,例如 "JavaScript" 或 "React"。
  • type: 題目的類型,我們先分為「概念題 (concept)」和「實作題 (code)」。
  • question: 題目的內容本文。

這些應該就足夠我們目前的介面使用了,不過考慮到未來我們還會加入的其他功能,比方說評分、難度和作答介面的提示等,我們勢必需要先盡可能的考量可能會需要的欄位,也許無法完全避免掉未來調整資料結構的命運,但至少我們能減少一點反覆調整的時間。
因此應該還要新增以下的欄位:

  • difficulty: 題目的難度,不管是分類篩選或是未來評分都需要用到。
  • hints: 提示的陣列,讓面試者可以取得需要的協助,同時也可以根據提示的進度讓AI提供更具體的回應。
  • keyPoints: 題目的關鍵概念,作為判斷使用者答案的基準與回饋的參考。
  • starterCode: 程式實作題的起手式。
  • testCases: 程式實作題的測試案例,用以檢測使用者提交的程式碼是否正確。

這樣一來,我們就可以很清楚地分類和管理我們的題庫了,馬上進入今天的主要目標,來建立資料庫吧!

Step 1.5: 定義型別介面

在我們實際建立「資料庫」之前,我們最好還是先定義一下相關的型別,請你在app/下建立一個新的資料夾types並在之中建立一個questions.ts檔案,寫入以下的內容:

// app/types/questions.ts
/**
 * 定義單一測試案例的結構
 */
export interface TestCase {
  name: string; // 測試案例的名稱,例如 "基本攤平"
  setup: string; // 執行測試前需要的前置程式碼,YOUR_CODE_HERE 會被替換成使用者的程式碼
  test: string; // 實際執行的測試程式碼
  expected: string; // 預期的 console.log 輸出結果
}

/**
 * 定義一個完整題目的結構
 */
export interface Question {
  id: string;
  topic: string;
  type: 'concept' | 'code';
  difficulty: 'easy' | 'medium' | 'hard';
  question: string;
  hints: string[];
  keyPoints: string[];
  starterCode: string | null;
  testCases: TestCase[] | null;
}

Step 2: 建立我們的 JSON 資料庫

接下來,我們就在專案裡手動建立這個「資料庫」吧。

在你的專案根目錄下,建立一個新的資料夾叫做 data,並在裡面新增一個檔案 questions.json

// 在專案根目錄執行
mkdir data
touch data/questions.json

然後,打開 data/questions.json,把下面這段內容貼進去。我先準備了幾題當作範例:


[
  {
    "id": "js-con-001",
    "topic": "JavaScript",
    "type": "concept",
    "difficulty": "easy",
    "question": "請解釋 JavaScript 中的 hoisting 是什麼?",
    "hints": [
      "想想變數宣告和函數宣告的行為",
      "var 和 let/const 的差異"
    ],
    "keyPoints": [
      "變數和函數宣告會被提升到其作用域的頂部",
      "只有宣告被提升,賦值不會",
      "let 和 const 也有 hoisting,但因存在暫時性死區 (TDZ),在宣告前存取會拋出錯誤"
    ],
    "starterCode": null,
    "testCases": null
  },
  {
    "id": "js-pro-001",
    "topic": "JavaScript",
    "type": "code",
    "difficulty": "medium",
    "question": "實作一個 flatten 函數,將巢狀陣列攤平成一維陣列",
    "starterCode": "function flatten(arr) {\n  // 例如:[1, [2, 3], [4, [5]]] => [1, 2, 3, 4, 5]\n}",
    "hints": [
      "可以用遞迴",
      "檢查元素是否為陣列用 Array.isArray()"
    ],
    "testCases": [
      {
        "name": "基本攤平",
        "setup": "const flatten = YOUR_CODE_HERE;",
        "test": "console.log(JSON.stringify(flatten([1, [2, 3]])));",
        "expected": "[1,2,3]"
      },
      {
        "name": "深層巢狀",
        "setup": "const flatten = YOUR_CODE_HERE;",
        "test": "console.log(JSON.stringify(flatten([1, [2, [3, [4]]]])));",
        "expected": "[1,2,3,4]"
      },
      {
        "name": "空陣列",
        "setup": "const flatten = YOUR_CODE_HERE;",
        "test": "console.log(JSON.stringify(flatten([])));",
        "expected": "[]"
      }
    ]
  },
  {
    "id": "react-con-001",
    "topic": "React",
    "type": "concept",
    "difficulty": "medium",
    "question": "請解釋 React 中的 Class Component 和 Functional Component 之間的差異,以及 Hooks 的出現帶來了什麼改變?",
    "hints": [
      "生命週期方法",
      "state 管理方式",
      "程式碼複用"
    ],
    "keyPoints": [
      "Class Component 使用 'this' 和 'extends React.Component'",
      "Functional Component 是純函式,過去被稱為無狀態元件",
      "Hooks 讓 Functional Component 也能擁有 state 和生命週期等特性",
      "Hooks (如 custom hooks) 改善了邏輯複用的問題,解決了 HOC/Render Props 的複雜性"
    ],
    "starterCode": null,
    "testCases": null
  },
  {
    "id": "js-pro-002",
    "topic": "JavaScript",
    "type": "code",
    "difficulty": "hard",
    "question": "實作一個 Promise.all() 的 polyfill,命名為 promiseAll",
    "starterCode": "function promiseAll(promises) {\n  // promises 是一個 promise 物件的陣列\n}",
    "hints": [
      "回傳一個新的 Promise",
      "需要一個計數器來追蹤已完成的 promise",
      "注意處理空陣列的邊界情況",
      "結果陣列的順序需要和傳入的 promises 陣列順序一致"
    ],
    "testCases": [
      {
        "name": "全部成功",
        "setup": "const promiseAll = YOUR_CODE_HERE; const p1 = Promise.resolve(1); const p2 = 2; const p3 = new Promise((res) => setTimeout(() => res(3), 100));",
        "test": "promiseAll([p1, p2, p3]).then(vals => console.log(JSON.stringify(vals)));",
        "expected": "[1,2,3]"
      },
      {
        "name": "其中一個失敗",
        "setup": "const promiseAll = YOUR_CODE_HERE; const p1 = Promise.resolve(1); const p2 = Promise.reject('error');",
        "test": "promiseAll([p1, p2]).catch(err => console.log(err));",
        "expected": "error"
      },
      {
        "name": "傳入空陣列",
        "setup": "const promiseAll = YOUR_CODE_HERE;",
        "test": "promiseAll([]).then(vals => console.log(JSON.stringify(vals)));",
        "expected": "[]"
      }
    ]
  },
  {
    "id": "css-con-001",
    "topic": "CSS",
    "type": "concept",
    "difficulty": "easy",
    "question": "請解釋 CSS Box Model (盒子模型) 是什麼,以及 `box-sizing: border-box;` 的作用?",
    "hints": [
      "content, padding, border, margin",
      "width 和 height 的計算方式"
    ],
    "keyPoints": [
      "標準盒子模型的 width/height 只包含 content",
      "border-box 的 width/height 包含 content, padding, 和 border",
      "`box-sizing` 改變了寬高計算的行為,讓排版更直觀"
    ],
    "starterCode": null,
    "testCases": null
  }
]

以下幾個點特別說明關於資料結構的部分:

  1. id的命名方式我們採用{topic}-{type}-{number}的格式,其中type的部分我們用con表示概念題、pro表示程式實作題,除了取英文前面的一部分作為縮寫之外,另外就是我想玩一下pro & con的雙關,請原諒我。
    因此每個id的組成部分會分為以下幾種表示方式:
  • topic: js, react, css
  • type: con (概念), pro (程式)
  • number: 001, 002...
  1. 有些欄位像是testCases的部分你會發現僅有一些有值,那是對應不同類型的題目,比方說概念題自然就不會有testCases和starterCode之類的欄位,這與我們剛剛的介面設計彼此呼應。

Step 3: 建立讀取題庫的 API

現在型別定義好了、資料也有了!我們需要一個方法讓前端能拿到這些資料。最好的方式就是建立一個專門的 API Endpoint。這也是我們選擇 Next.js 的好處之一,建立後端 API 就像建立一個頁面一樣簡單,還記得我們怎麼建立 Gemini API EndPoint 的吧?請你在 app/api/ 資料夾下,建立一個新的資料夾 questions,並在裡面新增 route.ts 檔案。

到目前為止,你的資料夾結構應該是這樣的,你可以先比對一下是否正確再繼續。

ai-frontend-interviewer/
├── app/
│   ├── api/
│   │   ├── gemini/
│   │   │   └── route.ts       
│   │   └── questions/         
│   │       └── route.ts       
│   ├── types/
│   │   └── questions.ts     
│   ├── layout.tsx           
│   └── page.tsx             
├── data/
│   └── questions.json       
├── node_modules/
├── public/
├── .env.local               
├── package.json
└── tsconfig.json

確認結構沒問題後我們就撰寫那個簡單的 API Endpoint 吧!

// app/api/questions/route.ts
import { NextResponse } from 'next/server';
import path from 'path';
import { promises as fs } from 'fs';

export async function GET() {
  try {
    // 找到 public 資料夾的路徑
    const jsonDirectory = path.join(process.cwd(), 'data');
    // 讀取 JSON 檔案
    const fileContents = await fs.readFile(jsonDirectory + '/questions.json', 'utf8');
    // 解析 JSON 內容
    const questions = JSON.parse(fileContents);
    
    // 從題庫中隨機選一題
    const randomQuestion = questions[Math.floor(Math.random() * questions.length)];

    return NextResponse.json(randomQuestion);
  } catch (error) {
    console.error(error);
    return NextResponse.json({ error: '無法讀取題庫' }, { status: 500 });
  }
}

程式碼說明:

  1. 我們建立了一個 GET 處理函式,所以當我們用 GET 方法請求 /api/questions 這個網址時,這段程式碼就會執行。
  2. path.join(process.cwd(), 'data') 是一個在 Node.js 環境中取得檔案絕對路徑的安全作法。process.cwd() 會回傳專案的根目錄。
  3. fs.readFile 是 Node.js 內建用來讀取檔案的模組,我們用它來讀取 questions.json 的內容。
  4. 讀取到的是純文字,所以我們用 JSON.parse() 把它轉換成 JavaScript 的陣列物件。
  5. 最後,用 Math.random() 從陣列中隨機挑選一題,並用 NextResponse.json() 回傳給前端。

現在,你可以試著啟動專案

npm run dev

然後在瀏覽器打開 http://localhost:3000/api/questions,你會發現每次重新整理,都會得到一題不一樣的題目!

圖1
圖1 :API 測試圖

Step 4: 修改前端,動態載入題目

好啦!最後一步!就是讓我們 Day 1 打造的介面 去呼叫這個新 API,而不是顯示寫死的題目。
再次打開 app/page.tsx,我們需要做一些修改,讓它不要再這麼死,該起來工作了。

import { useState, useEffect } from 'react'; // 引入useEffect
import { Question } from './types/question'; // 引入我們定義的型別

export default function Home() {
  const [answer, setAnswer] = useState('');
  const [feedback, setFeedback] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [currentQuestion, setCurrentQuestion] = useState<Question | null>(null); // 加入currentQuestion state
  const [isFetchingQuestion, setIsFetchingQuestion] = useState(false); // 加入isFetchingQuestion state

  useEffect(() => {
    const fetchQuestion = async () => {
      try {
        setIsFetchingQuestion(true);
        const response = await fetch('/api/questions');
        const data = await response.json();
        setCurrentQuestion(data);
      } catch (error) {
        console.error('無法抓取題目:', error);
      } finally {
        setIsFetchingQuestion(false);
      }
    };
    fetchQuestion();
  }, []);
    
}

  const handleSubmit = async () => {
    if (!answer) return;
    try {
      setIsLoading(true);

      // 發送請求到我們剛剛在後端app/api/gemini/route.ts建立的API
      const response = await fetch('/api/gemini', {
        method: 'POST',
        body: JSON.stringify({
          question: currentQuestion?.question, // 在這邊用我們新的currentQuestion變數
          answer,
        }),
      });
      const data = await response.json();

      setFeedback(data.result);
    } catch (error) {
      console.error('錯誤:', error);
    } finally {
      setIsLoading(false);
    }
  };
// ...中間省略

return (
    <main className="min-h-screen bg-gray-900 text-white">
      <div className="container mx-auto px-4 py-16">
        <h1 className="text-4xl font-bold text-center mb-8">
          AI 前端面試官 🤖
        </h1>

        <div className="max-w-2xl mx-auto">
          {/* 題目區 */}
          <div className="bg-gray-800 rounded-lg p-6 mb-6">
            {isFetchingQuestion ? (
              <p className="text-center text-gray-400">正在從題庫抽取題目...</p>
            ) : (
              currentQuestion && (
                <>
                  <div className="text-sm text-blue-400 mb-2">
                    {currentQuestion.topic} 題目
                  </div>
                  <p className="text-lg">{currentQuestion.question}</p>
                </>
              )
            )}
          </div>
         // 以下省略...   
)    

主要改動:

  • 引入useEffect在組件掛載時替我們從後端請求隨機一個問題展示在頁面上。
  • 引入定義好的Question型別
  • 修改handleSubmit函數讓它不再用硬編碼的問題。
  • 加入了isFetchingQuestioncurrentQuestion並修改問題區的 UI 。

儲存檔案後,回到你的瀏覽器 http://localhost:3000,你會發現頁面每次重新整理,都會顯示一題從 questions.json 隨機抽出的新題目!我們的頁面終於不再只會問 hoisting 了,暫時擺脫了薪水小偷的稱號。

圖2
圖2 :API 整合後畫面

今日回顧

辛苦啦! 果然動手寫程式碼還是稍稍有趣一些,今天的內容相對輕鬆很多,都是我們作為前端工程師最為熟悉的部分,來回顧一下今天的進度:
✅ 設計了題目的資料結構
✅ 用 JSON 檔案建立了我們的第一個「資料庫」
✅ 建立了一個後端 API 來隨機讀取題目
✅ 讓前端介面能動態載入題目

雖然只是一個簡單的 JSON 檔,但實際上與我們最後要使用的資料庫格式其實也相差不遠,作為一個 POC 是完全足夠的,很多時候真的不要在一開始就試圖加入過多的設置,尤其是你還對整個專案所選用的技術沒有太多了解時(例如我現在這樣,坦白說雖然規劃是做好了,但就像我跟你們坦白過的,那些也並不是我涉略過的領域,我與你們一樣都在摸索!),快速出個可用的版本再做優化會更適合我們的情況!

明天預告

現在我們的問答系統有了雛形,但對於一個「面試官」來說,光能問答還不夠,特別是當我們未來要處理程式題時。一個陽春的 絕對不夠看。明天(Day 5),我們要來點專業的:整合跟 VS Code 同款核心的 Monaco Editor,讓我們的作答區瞬間升級,擁有語法高亮、程式碼提示等強大功能!

今天也辛苦啦,我們明天見!🚀

今日程式碼: GitHub Day-04 Branch


上一篇
設計我們的面試系統:提出基本架構設計
下一篇
打造專業作答區:整合 Monaco Editor
系列文
前端工程師的AI應用開發實戰:30天從Prompt到Production - 以打造AI前端面試官為例8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言